baubs git
Commit cd43a64bc42e3c4927a2755bfd54a96465a2c3bd
Parents : 30d87f8
Author : Jan-Henrik Bruhn <hi@jhbruhn.de>
Date : 2025-12-05T23:38:23+01:00
Migrate to declarative react-konva and add pattern offset caching
React-Konva Migration:
- Create KonvaComponents.tsx with declarative components
- Convert Grid, Origin, Hoop, Stitches, PatternBounds, and CurrentPosition to React components
- Add React.memo and useMemo for performance optimization
- Remove imperative layer manipulation (destroyChildren, add, batchDraw)
- Remove backgroundLayerRef and patternLayerRef
- Let React handle component lifecycle and updates
- Improve performance through React's diffing algorithm
Pattern Offset Persistence:
- Add patternOffset field to CachedPattern interface
- Update PatternCacheService.savePattern to accept and store offset
- Modify useBrotherMachine to save offset when uploading pattern
- Update resumedPattern state to include offset information
- Restore cached pattern offset in App.tsx on resume
- Add initialPatternOffset prop to PatternCanvas component
- Pattern position now persists across page reloads and reconnections
Benefits:
- More maintainable and React-idiomatic code
- Better performance with large patterns
- Automatic cleanup and no memory leaks
- Pattern positioning workflow preserved across sessions
- Improved developer experience
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Changes
5 files changed, 291 insertions(+), 85 deletions(-)
Diff
diff --git a/src/App.tsx b/src/App.tsx
index 61f83f0..a12e5cb 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -32,8 +32,12 @@ function App() {
// Auto-load cached pattern when available
useEffect(() => {
if (machine.resumedPattern && !pesData) {
- console.log('[App] Loading resumed pattern:', machine.resumeFileName);
- setPesData(machine.resumedPattern);
+ console.log('[App] Loading resumed pattern:', machine.resumeFileName, 'Offset:', machine.resumedPattern.patternOffset);
+ setPesData(machine.resumedPattern.pesData);
+ // Restore the cached pattern offset
+ if (machine.resumedPattern.patternOffset) {
+ setPatternOffset(machine.resumedPattern.patternOffset);
+ }
}
}, [machine.resumedPattern, pesData, machine.resumeFileName]);
@@ -106,6 +110,7 @@ function App() {
pesData={pesData}
sewingProgress={machine.sewingProgress}
machineInfo={machine.machineInfo}
+ initialPatternOffset={patternOffset}
onPatternOffsetChange={handlePatternOffsetChange}
/>
</div>
diff --git a/src/components/KonvaComponents.tsx b/src/components/KonvaComponents.tsx
new file mode 100644
index 0000000..58ab887
--- /dev/null
+++ b/src/components/KonvaComponents.tsx
@@ -0,0 +1,229 @@
+import { memo, useMemo } from 'react';
+import { Group, Line, Rect, Text, Circle } from 'react-konva';
+import type { PesPatternData } from '../utils/pystitchConverter';
+import { getThreadColor } from '../utils/pystitchConverter';
+import type { MachineInfo } from '../types/machine';
+
+const MOVE = 0x10;
+
+interface GridProps {
+ gridSize: number;
+ bounds: { minX: number; maxX: number; minY: number; maxY: number };
+ machineInfo: MachineInfo | null;
+}
+
+export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
+ const lines = useMemo(() => {
+ const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
+ const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : bounds.maxX;
+ const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : bounds.minY;
+ const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
+
+ const verticalLines: number[][] = [];
+ const horizontalLines: number[][] = [];
+
+ // Vertical lines
+ for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
+ verticalLines.push([x, gridMinY, x, gridMaxY]);
+ }
+
+ // Horizontal lines
+ for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
+ horizontalLines.push([gridMinX, y, gridMaxX, y]);
+ }
+
+ return { verticalLines, horizontalLines };
+ }, [gridSize, bounds, machineInfo]);
+
+ return (
+ <Group name="grid">
+ {lines.verticalLines.map((points, i) => (
+ <Line
+ key={`v-${i}`}
+ points={points}
+ stroke="#e0e0e0"
+ strokeWidth={1}
+ />
+ ))}
+ {lines.horizontalLines.map((points, i) => (
+ <Line
+ key={`h-${i}`}
+ points={points}
+ stroke="#e0e0e0"
+ strokeWidth={1}
+ />
+ ))}
+ </Group>
+ );
+});
+
+Grid.displayName = 'Grid';
+
+export const Origin = memo(() => {
+ return (
+ <Group name="origin">
+ <Line points={[-10, 0, 10, 0]} stroke="#888" strokeWidth={2} />
+ <Line points={[0, -10, 0, 10]} stroke="#888" strokeWidth={2} />
+ </Group>
+ );
+});
+
+Origin.displayName = 'Origin';
+
+interface HoopProps {
+ machineInfo: MachineInfo;
+}
+
+export const Hoop = memo(({ machineInfo }: HoopProps) => {
+ const { maxWidth, maxHeight } = machineInfo;
+ const hoopLeft = -maxWidth / 2;
+ const hoopTop = -maxHeight / 2;
+
+ return (
+ <Group name="hoop">
+ <Rect
+ x={hoopLeft}
+ y={hoopTop}
+ width={maxWidth}
+ height={maxHeight}
+ stroke="#2196F3"
+ strokeWidth={3}
+ dash={[10, 5]}
+ />
+ <Text
+ x={hoopLeft + 10}
+ y={hoopTop + 10}
+ text={`Hoop: ${(maxWidth / 10).toFixed(0)} x ${(maxHeight / 10).toFixed(0)} mm`}
+ fontSize={14}
+ fontFamily="sans-serif"
+ fontStyle="bold"
+ fill="#2196F3"
+ />
+ </Group>
+ );
+});
+
+Hoop.displayName = 'Hoop';
+
+interface PatternBoundsProps {
+ bounds: { minX: number; maxX: number; minY: number; maxY: number };
+}
+
+export const PatternBounds = memo(({ bounds }: PatternBoundsProps) => {
+ const { minX, maxX, minY, maxY } = bounds;
+ const width = maxX - minX;
+ const height = maxY - minY;
+
+ return (
+ <Rect
+ x={minX}
+ y={minY}
+ width={width}
+ height={height}
+ stroke="#ff0000"
+ strokeWidth={2}
+ dash={[5, 5]}
+ />
+ );
+});
+
+PatternBounds.displayName = 'PatternBounds';
+
+interface StitchesProps {
+ stitches: number[][];
+ pesData: PesPatternData;
+ currentStitchIndex: number;
+}
+
+export const Stitches = memo(({ stitches, pesData, currentStitchIndex }: StitchesProps) => {
+ const stitchGroups = useMemo(() => {
+ interface StitchGroup {
+ color: string;
+ points: number[];
+ completed: boolean;
+ isJump: boolean;
+ }
+
+ const groups: StitchGroup[] = [];
+ let currentGroup: StitchGroup | null = null;
+
+ for (let i = 0; i < stitches.length; i++) {
+ const stitch = stitches[i];
+ const [x, y, cmd, colorIndex] = stitch;
+ const isCompleted = i < currentStitchIndex;
+ const isJump = (cmd & MOVE) !== 0;
+ const color = getThreadColor(pesData, colorIndex);
+
+ // Start new group if color/status/type changes
+ if (
+ !currentGroup ||
+ currentGroup.color !== color ||
+ currentGroup.completed !== isCompleted ||
+ currentGroup.isJump !== isJump
+ ) {
+ currentGroup = {
+ color,
+ points: [x, y],
+ completed: isCompleted,
+ isJump,
+ };
+ groups.push(currentGroup);
+ } else {
+ currentGroup.points.push(x, y);
+ }
+ }
+
+ return groups;
+ }, [stitches, pesData, currentStitchIndex]);
+
+ return (
+ <Group name="stitches">
+ {stitchGroups.map((group, i) => (
+ <Line
+ key={i}
+ points={group.points}
+ stroke={group.isJump ? (group.completed ? '#cccccc' : '#e8e8e8') : group.color}
+ strokeWidth={1.5}
+ lineCap="round"
+ lineJoin="round"
+ dash={group.isJump ? [3, 3] : undefined}
+ opacity={group.isJump ? 1 : (group.completed ? 1.0 : 0.3)}
+ />
+ ))}
+ </Group>
+ );
+});
+
+Stitches.displayName = 'Stitches';
+
+interface CurrentPositionProps {
+ currentStitchIndex: number;
+ stitches: number[][];
+}
+
+export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPositionProps) => {
+ if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
+ return null;
+ }
+
+ const [x, y] = stitches[currentStitchIndex];
+
+ return (
+ <Group name="currentPosition">
+ <Circle
+ x={x}
+ y={y}
+ radius={8}
+ fill="rgba(255, 0, 0, 0.3)"
+ stroke="#ff0000"
+ strokeWidth={3}
+ />
+ <Line points={[x - 12, y, x - 3, y]} stroke="#ff0000" strokeWidth={2} />
+ <Line points={[x + 12, y, x + 3, y]} stroke="#ff0000" strokeWidth={2} />
+ <Line points={[x, y - 12, x, y - 3]} stroke="#ff0000" strokeWidth={2} />
+ <Line points={[x, y + 12, x, y + 3]} stroke="#ff0000" strokeWidth={2} />
+ </Group>
+ );
+});
+
+CurrentPosition.displayName = 'CurrentPosition';
diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx
index b76823d..58c3fcf 100644
--- a/src/components/PatternCanvas.tsx
+++ b/src/components/PatternCanvas.tsx
@@ -3,34 +3,35 @@ import { Stage, Layer, Group } from 'react-konva';
import Konva from 'konva';
import type { PesPatternData } from '../utils/pystitchConverter';
import type { SewingProgress, MachineInfo } from '../types/machine';
-import {
- renderGrid,
- renderOrigin,
- renderHoop,
- renderStitches,
- renderPatternBounds,
- calculateInitialScale,
-} from '../utils/konvaRenderers';
+import { calculateInitialScale } from '../utils/konvaRenderers';
+import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents';
interface PatternCanvasProps {
pesData: PesPatternData | null;
sewingProgress: SewingProgress | null;
machineInfo: MachineInfo | null;
+ initialPatternOffset?: { x: number; y: number };
onPatternOffsetChange?: (offsetX: number, offsetY: number) => void;
}
-export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternOffsetChange }: PatternCanvasProps) {
+export function PatternCanvas({ pesData, sewingProgress, machineInfo, initialPatternOffset, onPatternOffsetChange }: PatternCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
- const backgroundLayerRef = useRef<Konva.Layer | null>(null);
- const patternLayerRef = useRef<Konva.Layer | null>(null);
const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1);
- const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 });
+ const [patternOffset, setPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 });
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1);
+ // Update pattern offset when initialPatternOffset changes
+ useEffect(() => {
+ if (initialPatternOffset) {
+ setPatternOffset(initialPatternOffset);
+ console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset);
+ }
+ }, [initialPatternOffset]);
+
// Track container size
useEffect(() => {
if (!containerRef.current) return;
@@ -166,61 +167,6 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
}
}, [onPatternOffsetChange]);
- const handlePatternDragMove = useCallback(() => {
- // Just for visual feedback during drag
- }, []);
-
- // Render background layer content
- const renderBackgroundLayer = useCallback((layer: Konva.Layer) => {
- if (!pesData) return;
-
- layer.destroyChildren();
-
- const { bounds } = pesData;
- const gridSize = 100; // 10mm grid (100 units in 0.1mm)
-
- renderGrid(layer, gridSize, bounds, machineInfo);
- renderOrigin(layer);
-
- if (machineInfo) {
- renderHoop(layer, machineInfo);
- }
-
- layer.batchDraw();
- }, [pesData, machineInfo]);
-
- // Render pattern layer content
- const renderPatternLayer = useCallback((layer: Konva.Layer, group: Konva.Group) => {
- if (!pesData) return;
-
- group.destroyChildren();
-
- const currentStitch = sewingProgress?.currentStitch || 0;
- const { stitches, bounds } = pesData;
-
- renderStitches(group, stitches, pesData, currentStitch);
- renderPatternBounds(group, bounds);
-
- layer.batchDraw();
- }, [pesData, sewingProgress]);
-
- // Update background layer when deps change
- useEffect(() => {
- if (backgroundLayerRef.current) {
- renderBackgroundLayer(backgroundLayerRef.current);
- }
- }, [renderBackgroundLayer]);
-
- // Update pattern layer when deps change
- useEffect(() => {
- if (patternLayerRef.current) {
- const patternGroup = patternLayerRef.current.findOne('.pattern-group') as Konva.Group;
- if (patternGroup) {
- renderPatternLayer(patternLayerRef.current, patternGroup);
- }
- }
- }, [renderPatternLayer]);
-
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Pattern Preview</h2>
@@ -253,10 +199,22 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
}}
>
{/* Background layer: grid, origin, hoop */}
- <Layer ref={backgroundLayerRef} />
+ <Layer>
+ {pesData && (
+ <>
+ <Grid
+ gridSize={100}
+ bounds={pesData.bounds}
+ machineInfo={machineInfo}
+ />
+ <Origin />
+ {machineInfo && <Hoop machineInfo={machineInfo} />}
+ </>
+ )}
+ </Layer>
{/* Pattern layer: draggable stitches and bounds */}
- <Layer ref={patternLayerRef}>
+ <Layer>
{pesData && (
<Group
name="pattern-group"
@@ -264,7 +222,6 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
x={patternOffset.x}
y={patternOffset.y}
onDragEnd={handlePatternDragEnd}
- onDragMove={handlePatternDragMove}
onMouseEnter={(e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'move';
@@ -273,14 +230,26 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'grab';
}}
- />
+ >
+ <Stitches
+ stitches={pesData.stitches}
+ pesData={pesData}
+ currentStitchIndex={sewingProgress?.currentStitch || 0}
+ />
+ <PatternBounds bounds={pesData.bounds} />
+ </Group>
)}
</Layer>
{/* Current position layer */}
<Layer>
{pesData && sewingProgress && sewingProgress.currentStitch > 0 && (
- <Group x={patternOffset.x} y={patternOffset.y} />
+ <Group x={patternOffset.x} y={patternOffset.y}>
+ <CurrentPosition
+ currentStitchIndex={sewingProgress.currentStitch}
+ stitches={pesData.stitches}
+ />
+ </Group>
)}
</Layer>
</Stage>
diff --git a/src/hooks/useBrotherMachine.ts b/src/hooks/useBrotherMachine.ts
index 3d6e490..1847c22 100644
--- a/src/hooks/useBrotherMachine.ts
+++ b/src/hooks/useBrotherMachine.ts
@@ -29,7 +29,7 @@ export function useBrotherMachine() {
const [isPolling, setIsPolling] = useState(false);
const [resumeAvailable, setResumeAvailable] = useState(false);
const [resumeFileName, setResumeFileName] = useState<string | null>(null);
- const [resumedPattern, setResumedPattern] = useState<PesPatternData | null>(
+ const [resumedPattern, setResumedPattern] = useState<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null>(
null,
);
@@ -58,11 +58,11 @@ export function useBrotherMachine() {
const cached = PatternCacheService.getPatternByUUID(uuidStr);
if (cached) {
- console.log("[Resume] Pattern found in cache:", cached.fileName);
+ console.log("[Resume] Pattern found in cache:", cached.fileName, "Offset:", cached.patternOffset);
console.log("[Resume] Auto-loading cached pattern...");
setResumeAvailable(true);
setResumeFileName(cached.fileName);
- setResumedPattern(cached.pesData);
+ setResumedPattern({ pesData: cached.pesData, patternOffset: cached.patternOffset });
// Fetch pattern info from machine
try {
@@ -166,7 +166,7 @@ export function useBrotherMachine() {
}, [service, isConnected]);
const loadCachedPattern =
- useCallback(async (): Promise<PesPatternData | null> => {
+ useCallback(async (): Promise<{ pesData: PesPatternData; patternOffset?: { x: number; y: number } } | null> => {
if (!resumeAvailable) return null;
try {
@@ -177,10 +177,10 @@ export function useBrotherMachine() {
const cached = PatternCacheService.getPatternByUUID(uuidStr);
if (cached) {
- console.log("[Resume] Loading cached pattern:", cached.fileName);
+ console.log("[Resume] Loading cached pattern:", cached.fileName, "Offset:", cached.patternOffset);
// Refresh pattern info from machine
await refreshPatternInfo();
- return cached.pesData;
+ return { pesData: cached.pesData, patternOffset: cached.patternOffset };
}
return null;
@@ -212,10 +212,10 @@ export function useBrotherMachine() {
);
setUploadProgress(100);
- // Cache the pattern with its UUID
+ // Cache the pattern with its UUID and offset
const uuidStr = uuidToString(uuid);
- PatternCacheService.savePattern(uuidStr, pesData, fileName);
- console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr);
+ PatternCacheService.savePattern(uuidStr, pesData, fileName, patternOffset);
+ console.log("[Cache] Saved pattern:", fileName, "with UUID:", uuidStr, "Offset:", patternOffset);
// Clear resume state since we just uploaded
setResumeAvailable(false);
diff --git a/src/services/PatternCacheService.ts b/src/services/PatternCacheService.ts
index b12f5b6..bde64af 100644
--- a/src/services/PatternCacheService.ts
+++ b/src/services/PatternCacheService.ts
@@ -5,6 +5,7 @@ interface CachedPattern {
pesData: PesPatternData;
fileName: string;
timestamp: number;
+ patternOffset?: { x: number; y: number };
}
const CACHE_KEY = 'brother_pattern_cache';
@@ -34,7 +35,8 @@ export class PatternCacheService {
static savePattern(
uuid: string,
pesData: PesPatternData,
- fileName: string
+ fileName: string,
+ patternOffset?: { x: number; y: number }
): void {
try {
// Convert penData Uint8Array to array for JSON serialization
@@ -48,10 +50,11 @@ export class PatternCacheService {
pesData: pesDataWithArrayPenData,
fileName,
timestamp: Date.now(),
+ patternOffset,
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
- console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid);
+ console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid, 'Offset:', patternOffset);
} catch (err) {
console.error('[PatternCache] Failed to save pattern:', err);
// If quota exceeded, clear and try again
Served by rngit 1.4.2 - Generated in 0.16s